custom table component

面试要求实现table + pagination + search这些功能,先看一下思路。先不考虑nextjs的server components,都默认是client components。

思路

我不建议一上来就写代码。

Senior 的做法是先说设计思路。

第一步:确认是前端分页还是服务端分页

我会先问:

数据量大概是多少?是前端分页还是后端分页?

What's the approximate size of the data we are dealing with?

Should the pagination be handled on the frontend or the backend?

因为这是两个完全不同的实现。

前端分页

例如:

100 条数据已经全部拿到。

前端负责:

数据流:


服务端分页

真实项目更常见:

后端返回:

此时:

都需要重新请求 API。

 

第二步:拆组件

不要全部写在一个组件。

我会这样拆:

职责:

 

第三步:状态设计

如果是前端分页:

只有两个状态:


不要存:

不要存:

因为它们都是:

可以计算出来。

 

第四步:数据流设计

先过滤:


再分页:


面试时可以直接说:

而不是:


因为用户希望:


例如:

而不是:

 

第五步:搜索和分页联动

这里特别容易考。

当用户搜索:

必须:

否则:

这是经典 Bug。

面试官特别喜欢问。

 

第六步:Pagination 设计

我会这样设计:

组件只负责:

不要让 Pagination 知道:

 

第七步:性能优化

如果面试官继续追问:

数据量 5000 条怎么办?

我会回答:

避免每次 render 都重新 filter。

如果:

我会说:

或者:

例如:

 

面试口语版(中文)

如果让我实现 Table、Search 和 Pagination,我会先确认是前端分页还是服务端分页。

对于前端分页,我会维护 search 和 currentPage 两个状态。数据流是先根据 search 过滤数据,再根据 currentPage 计算当前页的数据。

Table 只负责展示数据,Pagination 只负责切换页码,页面组件负责管理状态。

另外我会注意搜索时重置页码到第一页,避免搜索结果变少后出现空白页的问题。

如果数据量较大,我会使用 useMemo 优化过滤计算;如果数据量非常大,则考虑服务端分页或虚拟列表方案。

 

I would first clarify whether the requirement is client-side pagination or server-side pagination.

For client-side pagination, I would keep only two pieces of state: the search keyword and the current page. The data flow would be search filtering first, then pagination on the filtered result.

The Table component is responsible for rendering data, while the Pagination component only handles page navigation. State management stays in the parent component.

I would also reset the page number to page one whenever the search keyword changes to avoid empty pages.

For larger datasets, I would use memoization to optimize filtering, and for very large datasets I would switch to server-side pagination or list virtualization.

 

实现client side pagination

如果是面试手写,我会遵循一个原则:

很多候选人一上来就写:

结果 40 分钟过去了还没跑起来。

面试时更推荐:

第一步:定义数据

假设有这样的数据:

 

第二步:状态

只存真正需要的状态

不要存:

因为都能计算出来。

 

第三步:过滤数据

 

第四步:计算总页数

 

第五步:计算当前页数据

 

第六步:搜索时重置页码

这是面试高频坑。

 

第七步:完整实现

CSS重点

我看到th和td上面,都设置了border,但是实际效果却border没有累加,还是1px,为什么?

这是因为table上加了border-collapse的样式,等于下面这个样式:

开启后,相邻单元格的边框不会叠加,浏览器只保留一条边框。

这个真的好用,我之前很多样式问题都能靠这个来解决。

优化

搜索需要加上防抖。

方案一:Debounced State(面试最喜欢)

维护两个状态:

监听 search:

过滤时,从监听search改为监听debouncedSearch:

输入框内容不变:


执行过程:

封装成 Hook(生产环境)

很多团队会写:

使用:

过滤:

 

React 18/19 更现代的方案

其实对于 Client Side Search,很多时候根本不需要 Debounce。

可以使用:

过滤:

区别:

Debounce

useDeferredValue

用户体验通常更好。

对于 Client Side Search,如果过滤逻辑比较重,我会使用 Debounce 或 useDeferredValue 减少不必要的计算。对于 Server Side Search,我通常会结合 Debounce,避免用户输入过程中频繁发送网络请求。

English:

For client-side filtering, I usually use debounce or useDeferredValue when the filtering operation is expensive. For server-side search, I typically debounce the input to avoid sending unnecessary API requests while the user is still typing.

 

client side pagination 追问

通常下一步会问:

如果让我把 Table 抽成一个可复用组件呢?

这时候就可以开始第二阶段:

甚至做成:

这种类似于 TanStack Table 的设计。这个也是中高级 React 面试特别爱考的一步。


很好,这其实就是从:

的分水岭了。

很多中级工程师能写出:

但是高级工程师会开始思考:

总不能复制 4 份 Table 吧。

 

第一步:抽象 Table

我们先看哪些东西会变化。

变化的是:

固定的是:

所以我们抽:

 

第二步:设计 Columns

这是最经典的设计。

然后 Table 自动渲染:

 

第三步:支持自定义 Cell

面试官一定会问:

如果状态字段需要显示 Tag 怎么办?

例如:

不能直接:

要支持:

类似:

的设计。

 

第四步:TypeScript 泛型

这是高级面试必问。

定义:

keyof T:这是 TypeScript 的索引类型查询操作符,它会提取出类型 T 所有属性名(键)组成的联合类型。

value: T[keyof T]:当前单元格的值(对应字段的数据类型)。

row: T:当前行完整的数据对象,方便你根据同一行里的其他字段来决定怎么渲染当前单元格。

Table:

组件:

这样:

自动推导类型。

注意:

render是一个函数,所以当有render时,直接执行即可。

这里的CustomTable组件,我使用的时候,需要传递T类型过来吗?

  1. 自动推导(最常用、最省心 ⭐️⭐️⭐️)

当你把带有明确类型定义的 data 数组赋值给 CustomTabledata 属性时,TypeScript 会顺藤摸瓜,自动把 data 数组中元素的类型当做 T 传递进去

  1. 显式手动传递(当你想强制约束时 ⭐️⭐️)

有时候你的 data 可能是从后端接口异步拿到的,初始值是个空数组 [],此时 TS 无法自动推导出 T 的类型。你就需要像调用泛型函数一样,在 JSX 标签上显式地写出 <User>

 

第五步:Pagination 抽出来

刚刚我们写:

抽成:

职责:

这是组件设计原则:

 

第六步:SearchInput 抽出来

这样:

全部可复用。

 

 

第七步:父组件组合

最终:

内部:

状态全部在页面层。lift state up.

数据流:

效果:

面试官追问:还能继续优化吗?

这里才是真正的高级题。

你可以说:

方案1:抽 Hook

我会让 Hook 只负责分页状态和分页计算,不负责搜索和排序。Hook 接收数据和 pageSize,返回当前页数据、总页数以及分页操作方法。这样搜索、排序、过滤都可以在外部完成,分页 Hook 保持单一职责,也更容易复用。

 

方案2:DataTable

进一步组合:

内部直接包含:

类似 TanStack Table的设计思路。

Senior 面试口语版(中英双语)

如果只有一个表格,我会直接实现功能。但如果系统里有多个表格,例如用户、订单和商品列表,我会把 Table、Pagination 和 SearchInput 抽成独立组件。

Table 接收 data 和 columns,通过泛型保证类型安全。Pagination 只负责页码切换,不关心数据来源。SearchInput 只负责输入和事件通知。

页面组件负责状态管理和数据流控制,这样组件职责清晰,也更容易复用和测试。

 

If there is only one table, I would implement it directly. However, if the application contains multiple tables such as users, orders, and products, I would extract Table, Pagination, and SearchInput into reusable components.

The Table component would accept data and columns and use TypeScript generics for type safety. Pagination would only handle page navigation, while SearchInput would only manage user input.

The page component would own the state and control the data flow. This keeps responsibilities clear and makes the components easier to reuse and test.

 

 

client side pagination 追问

你刚刚设计的 Table,如何支持:

并且不把 Table 组件搞成一个 2000 行的怪物组件?

这就是很多公司会考的 DataTable 架构设计题


这一步就开始接近真正的 Senior 面试了。

因为现在的问题已经不是:

而是:

 

初级工程师的写法

很多人会这样写:

然后内部:

最后变成:

没人敢改。

 

Senior 的第一原则

不要把所有逻辑塞进 Table。

Table 应该只负责:

即:

而不是:

 

架构分层

我一般会拆成:

例如:

负责:

然后:

Table 根本不知道:

这些东西。

 

Sorting 怎么设计?

很多人的第一反应:

然后内部:

我一般不会这么做。

更推荐:

然后:

这是 React 最经典的模式:

类似:

 

为什么?

因为以后可能:

如果状态藏在 Table 里面:

 

Search 怎么设计?

同理:

不要:

而是:

因为:

 

Server Pagination 怎么设计?

很多候选人会这样写:

我一般不喜欢。

因为:

职责变重。

更推荐:

然后:

这样:

 

Row Selection

不要:

内部自己存:

而是:

传进去:

和 TanStack Table 一模一样。

 

Column Visibility

不要:

而是:

然后:

 

你会发现什么?

所有高级功能:

都遵循同一个模式:

即:

 

最终架构

我实际项目更喜欢:

状态:

然后:

 

面试口语版(高级)

对于复杂表格,我不会把搜索、排序、分页和选择状态放在 Table 内部,而是采用受控组件模式,把状态提升到页面层管理。Table 只负责展示数据和触发事件。这样可以方便地与 URL、服务端查询、缓存以及权限系统集成,同时也能避免 Table 组件变成一个难以维护的大组件。

 

For complex tables, I prefer a controlled component approach. Instead of keeping sorting, pagination, filtering, and row selection state inside the table, I lift those states to the page level. The table is only responsible for rendering data and emitting events. This makes it easier to integrate with URLs, server-side queries, caching, and permission systems while keeping the table component maintainable and reusable.

 

实现server side pagination

当然可以,而且现在的 React + Next.js 项目里,Server Side Pagination(服务端分页)反而是主流方案

如果面试官问:

你会实现 Server Side Pagination 吗?

你应该回答:

会。通常分页、搜索、排序条件都放在 URL 或状态中,然后将这些参数传递给后端 API,由数据库完成分页查询,只返回当前页的数据和总数。

 

最经典的流程

前端

或者:

API

后端

SQL:

同时查询:

返回

前端计算页数

 

Next.js App Router 方案

这是目前比较高频的面试答案。

URL

page.tsx

这样:

都会触发新的 Server Render。

这是非常符合 Next.js 思维的。

 

Client Component 如何翻页?

用户点击:

变成:

然后:

 

面试官可能继续追问

为什么不用前端分页?

例如:

缺点:

网络浪费

首屏变慢

内存占用

搜索不准确

例如:

如果只在当前页搜索:

实际上数据存在其他页。


所以大数据场景应该:

全部交给服务端。

 

Senior 级别回答

如果我是面试官,我比较喜欢听到这种答案:

对于小数据集,我会使用 Client Side Pagination,减少请求次数。

对于中大型数据集,我会采用 Server Side Pagination,把分页、搜索和排序参数传递给后端,由数据库完成查询。

在 Next.js App Router 中,我通常会将分页状态放入 URL Search Params,使页面具备可分享、可刷新和 SEO 友好的特性,然后由 Server Components 根据 searchParams 获取对应页的数据。

这已经是一个比较完整且偏生产环境的回答了。

 

 

server side pagination完整实现

我给你一个 Next.js 16 + App Router + Server Side Pagination 的完整例子。

目录结构

1. Server Component

2. Table

3. Pagination

真实项目会怎么写?

上面的代码能跑,但还不够生产级。

真实项目通常会把:

全部放进 URL。

例如:

然后统一解析:

然后请求:

如果用 Prisma

后端通常长这样:

总数:

返回:

 

面试加分点

如果面试官问:

为什么把分页状态放到 URL?

你可以回答:

中文:

English:

I usually keep pagination, filtering, and sorting state in URL search parameters because it makes the page shareable, bookmarkable, refresh-safe, and easier to integrate with Server Components in Next.js.

这个回答已经是 Senior Frontend 的水平了。

 

 

tanstack/table案例

一个参考案例

有一个很好的table案例,https://github.com/sadmann7/tablecn,如果不能直接使用,那么可以仿照写。这个案例也是使用tanstack/table来做的。代码在code文件夹里面,可以看到,还是非常复杂的。因为作者使用nextjs创建的项目,所以这个table是可以用在server component里面的,这就非常高级了。

这里的pagination和搜索使用到了nuqs这个库,useQueryState这个hook,将搜索条件和pagination相关参数放到了url上,然后从url上获取参数,然后触发更新。

这个案例是很复杂的,搞清楚了之后可以分享出来。

简单案例的实现步骤

table好做,直接照抄即可,tanstack/table里面第一个案例就做出来了:

image-20251224130247108

生成的表格是没有样式的,还需要自己加样式:

image-20251224140918275

困难的是pagination应该怎么做?

TanStack Table 的分页功能分为两种主要模式:客户端分页 和 服务端分页。 你现在的项目用的是服务端分页(manualPagination: true),这也是实际项目中最常用的方式。下面用最直白的语言解释它的实现原理。核心概念:

  1. 表格自己不存数据 TanStack Table 只是一个“渲染引擎”,它不负责存数据,也不自己切页。 它只负责:

    • 根据你给它的 data(当前页的数据)来渲染表格
    • 根据你给它的 pageCount(总页数)来显示分页控件
    • 当用户点“下一页”时,它会调用 onPaginationChange 通知你
  2. 分页状态由你自己管理 你需要用 React 的 state 来记录当前是第几页、每页几条:

  3. 点击翻页 → 触发 state 变化 → 重新请求数据

    当用户点“下一页”时发生的事:

    • 用户点击 → table.nextPage() 被调用
    • table 内部把 pageIndex +1
    • 调用 onPaginationChange(你设置的 setPagination)
    • state 更新 → React 重新渲染组件
    • useQuery 发现 queryKey 变了(因为包含了 pageIndex),自动重新请求后端
    • 后端返回新的那一页数据 → 表格显示新内容

    流程图(文字版):

代码中关键的几行

客户端分页 vs 服务端分页 对比(快速记忆)

特性客户端分页 (manualPagination: false)服务端分页 (manualPagination: true)
数据从哪来一次性把所有数据拿回来每次只拿当前页的数据
适合场景数据量很小(<1000条)数据量大、需要搜索、排序
性能前端压力大,首次加载慢后端承担分页逻辑,响应更快
实现难度简单,Table 自动处理需要自己管理 pageIndex 和请求
你现在用的是(推荐)

总结一句话“分页本质上就是:用户点翻页 → 更新 pageIndex → 带着新的页码重新请求后端 → 把后端返回的新数据塞给表格渲染” TanStack Table 只负责“通知你”和“渲染当前页”,真正的分页逻辑(切哪一页、请求哪一页)是你自己通过 state + useQuery 完成的。

看了案例之后,我发现不管是分页还是搜索,都是通过useQuery里面的queryKey来触发的。将pageNum、pageSize、keyword、各种搜索词这些状态放进queryKey里面去,当这些状态变化的时候,就会触发请求数据。

所以说react query真的解决了问题。

简单案例代码

就是code/real-project里面的TablePagination组件。这里就不粘贴了,代码还是比较多的。

简单案例的问题点

翻页时页面闪动

主要原因通常有 3 个:

  1. 数据请求期间,data 变为 undefined 或 null,表格瞬间渲染空数组 → 内容消失
  2. keepPreviousData 没开,导致旧数据被清空,新数据还没回来
  3. 组件重新渲染时,表格高度/布局跳动

第1和3都是写一些HTML+CSS,针对第二个,需要配置useQuery里面的placeholderData: keepPreviousData,在新数据回来前,保留旧数据,这样就不会在翻页的时候清空数据,新数据返回后重新渲染数据,造成很突兀的闪动情况。

同时也可以配置useQuery里面的gcTime和staleTime,让缓存数据存在的时间更长一些,显示起来就很快。

搜索框输入一个字母就会触发更新

①加防抖,编写一个防抖的hook。

②使用防抖hook新建一个变量,这个变量改变了才触发更新。

搜索框输入后,无法获取到最新的输入结果,造成返回结果不正确

这个其实是我的问题,因为上一个问题中,useQueryqueryKey里面的search要改为debouncedSearch,我没有改,所以造成这个问题。改了之后就好了。

固定列宽

①在列定义中指定宽度

首先,在你的 columns 配置中为特定列添加 size 属性。TanStack Table 默认的 size 值是 150。

②在渲染时应用样式 (Tailwind)

TanStack Table 本身不负责渲染样式,你需要手动将 size 应用到 thtd 上。为了确保宽度严格固定,建议使用 table-fixed 布局。

table 加上 table-fixed 类。这会告诉浏览器不要根据内容自动撑开列宽,而是遵循你设置的宽度。

在渲染 thtd 时,直接通过内联样式设置宽度:

注意:

如果width不起作用,那么添加min-width或者max-width,或者二者都添加。

溢出显示...,并且可以鼠标悬浮显示全部信息

①封装 OverflowTooltip 组件

这个组件会自动判断子元素是否溢出。如果是,则展示 Tooltip;如果不是,则只渲染原始文本。使用了shadcn的Tooltip组件。

②在 TanStack Table 的 columns 中应用

你可以直接在列定义的 cell 函数中使用该组件:

③关键 CSS 配合(提醒)

为了确保溢出检测(scrollWidth > clientWidth)准确生效,请务必检查以下两点:

  1. 表格布局:在 <table> 标签上必须有 table-fixed
  2. 单元格宽度:在 td 渲染时,需要显式设置宽度:

table和pagination单独抽离成组件